Описание проекта:
Необходимо провести исследовательский анализ данных и проанализировать влиятие событий на совершение целевого события. Также важно выявить какие сценарии использвания приложения выделяются и как различается время между событиями ADVERT_OPEN -> CONTACTS_SHOW И TIPS_CLICK -> CONTACTS_SHOW.Какая конверсия в целевое событие у данных действий?
После нужно проверить следующие статистические гипотезы:
tips_show и tips_click, другие — только tips_show. Проверьте гипотезу: конверсия в просмотры контактов различается у этих двух групп.photos_show, другие нет. Проверить гипотезу: пользователи, которые просматривают фотографии, чаще звонят по номеру из объявления (конверсия в звонки у групп различается).Описание данных:
В датасете содержатся данные пользователей, впервые совершивших действия в приложении после 7 октября 2019 года.
Колонки в mobile_sources.csv:
userId — идентификатор пользователя,source — источник, с которого пользователь установил приложение.Колонки в mobile_dataset.csv: **
event.time — время совершения,user.id — идентификатор пользователя,event.name — действие пользователя.Виды действий:
advert_open — открыл карточки объявления,photos_show — просмотрел фотографий в объявлении,tips_show — увидел рекомендованные объявления,tips_click — кликнул по рекомендованному объявлению,contacts_show и show_contacts — посмотрел номер телефона,contacts_call — позвонил по номеру из объявления,map — открыл карту объявлений,search_1—search_7 — разные действия, связанные с поиском по сайту,favorites_add — добавил объявление в избранное.Навыки и инструменты:
import pandas as pd
import numpy as np
import scipy.stats as stats
import seaborn as sns
import matplotlib.pyplot as plt
import datetime as dt
from plotly.offline import download_plotlyjs, init_notebook_mode, plot, iplot
import plotly.graph_objs as go
import plotly.express as px
import plotly.figure_factory as ff
import warnings
from scipy import stats as st
import numpy as np
import cmath as mth
import os
import re
import requests
import plotly.graph_objects as go
from tqdm.auto import tqdm
%config InlineBackend.figure_format = 'retina'
mobile_sources = pd.read_csv('https://code.s3.yandex.net/datasets/mobile_sources.csv')
mobile_dataset = pd.read_csv('https://code.s3.yandex.net/datasets/mobile_dataset.csv')
mobile_dataset.head(5)
| event.time | event.name | user.id | |
|---|---|---|---|
| 0 | 2019-10-07 00:00:00.431357 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 1 | 2019-10-07 00:00:01.236320 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 2 | 2019-10-07 00:00:02.245341 | tips_show | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| 3 | 2019-10-07 00:00:07.039334 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 4 | 2019-10-07 00:00:56.319813 | advert_open | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
mobile_dataset.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 74197 entries, 0 to 74196 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event.time 74197 non-null object 1 event.name 74197 non-null object 2 user.id 74197 non-null object dtypes: object(3) memory usage: 1.7+ MB
Всего в датасете mobile_dataset 74197 записей. Далее мы изменим названия солбцов и типы данных.
mobile_sources.head(5)
| userId | source | |
|---|---|---|
| 0 | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 1 | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
| 2 | 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 | yandex |
| 3 | d9b06b47-0f36-419b-bbb0-3533e582a6cb | other |
| 4 | f32e1e2a-3027-4693-b793-b7b3ff274439 |
mobile_sources.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 4293 entries, 0 to 4292 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 userId 4293 non-null object 1 source 4293 non-null object dtypes: object(2) memory usage: 67.2+ KB
В датасете mobile_sources 4293 записей. Далее также поменяем названия столбцов.
Заменим названия столбцов в датасете mobile_dataset на более удобные.
mobile_dataset.rename(columns = {'event.time':'event_time', 'event.name':'event_name','user.id':'user_id'}, inplace = True )
mobile_dataset.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 74197 entries, 0 to 74196 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_time 74197 non-null object 1 event_name 74197 non-null object 2 user_id 74197 non-null object dtypes: object(3) memory usage: 1.7+ MB
Также заменим названия столбцов в датасете mobile_sources.
mobile_sources.rename(columns = {'userId':'user_id'}, inplace = True )
mobile_sources.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 4293 entries, 0 to 4292 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 4293 non-null object 1 source 4293 non-null object dtypes: object(2) memory usage: 67.2+ KB
Также, для удобства исследования соединим два датасета.
data=mobile_sources.merge(mobile_dataset,how='left')
data
| user_id | source | event_time | event_name | |
|---|---|---|---|---|
| 0 | 020292ab-89bc-4156-9acf-68bc2783f894 | other | 2019-10-07 00:00:00.431357 | advert_open |
| 1 | 020292ab-89bc-4156-9acf-68bc2783f894 | other | 2019-10-07 00:00:01.236320 | tips_show |
| 2 | 020292ab-89bc-4156-9acf-68bc2783f894 | other | 2019-10-07 00:00:07.039334 | tips_show |
| 3 | 020292ab-89bc-4156-9acf-68bc2783f894 | other | 2019-10-07 00:01:27.770232 | advert_open |
| 4 | 020292ab-89bc-4156-9acf-68bc2783f894 | other | 2019-10-07 00:01:34.804591 | tips_show |
| ... | ... | ... | ... | ... |
| 74192 | d157bffc-264d-4464-8220-1cc0c42f43a9 | 2019-11-03 23:46:47.068179 | map | |
| 74193 | d157bffc-264d-4464-8220-1cc0c42f43a9 | 2019-11-03 23:46:58.914787 | advert_open | |
| 74194 | d157bffc-264d-4464-8220-1cc0c42f43a9 | 2019-11-03 23:47:01.232230 | tips_show | |
| 74195 | d157bffc-264d-4464-8220-1cc0c42f43a9 | 2019-11-03 23:47:47.475102 | advert_open | |
| 74196 | d157bffc-264d-4464-8220-1cc0c42f43a9 | 2019-11-03 23:47:50.087645 | tips_show |
74197 rows × 4 columns
Заменим тип данных в формате времени.
data['event_time'] = pd.to_datetime(data['event_time'], format='%Y.%m.%d %H:%M:%S').dt.round('1S')
data.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 74197 entries, 0 to 74196 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 74197 non-null object 1 source 74197 non-null object 2 event_time 74197 non-null datetime64[ns] 3 event_name 74197 non-null object dtypes: datetime64[ns](1), object(3) memory usage: 2.8+ MB
data.isna().sum()
user_id 0 source 0 event_time 0 event_name 0 dtype: int64
Пропусков в датасете нет. Посмотрим на дубликаты.
data.duplicated().sum()
1118
Так как выше мы округляли значения времени, возможно могли появиться дубликаты. Оставим их для корректности исследования.
session_id¶data=data.sort_values(['user_id','event_time'])
data
| user_id | source | event_time | event_name | |
|---|---|---|---|---|
| 2171 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:39:46 | tips_show |
| 2172 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:40:31 | tips_show |
| 2173 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:41:06 | tips_show |
| 2174 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:43:21 | tips_show |
| 2175 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:45:31 | tips_show |
| ... | ... | ... | ... | ... |
| 19048 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 15:51:24 | tips_show | |
| 19049 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 15:51:58 | contacts_show | |
| 19050 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 16:07:41 | tips_show | |
| 19051 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 16:08:18 | tips_show | |
| 19052 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 16:08:25 | tips_show |
74197 rows × 4 columns
В среднем в аналитике (больше в веб-аналитике) выделяют дефолтный тайм-аут в 30 минут. В целом, можем принять за наш тайм-аут, за это время пользователь сможет просмотреть фото, контакты и даже совершить звонок. По данным Google Analytics (https://support.google.com/analytics/answer/2731565?hl=ru#zippy=%2C%D1%81%D0%BE%D0%B4%D0%B5%D1%80%D0%B6%D0%B0%D0%BD%D0%B8%D0%B5).
В целом, значение в 30 секунд выглядит адекватным. Подставим в формулу.
g = (data.groupby('user_id')['event_time'].diff() > pd.Timedelta('30Min')).cumsum()
data['session_id'] = data.groupby(['user_id', g], sort=False).ngroup() + 1
data
| user_id | source | event_time | event_name | session_id | |
|---|---|---|---|---|---|
| 2171 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:39:46 | tips_show | 1 |
| 2172 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:40:31 | tips_show | 1 |
| 2173 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:41:06 | tips_show | 1 |
| 2174 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:43:21 | tips_show | 1 |
| 2175 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 13:45:31 | tips_show | 1 |
| ... | ... | ... | ... | ... | ... |
| 19048 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 15:51:24 | tips_show | 10368 | |
| 19049 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 15:51:58 | contacts_show | 10368 | |
| 19050 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 16:07:41 | tips_show | 10368 | |
| 19051 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 16:08:18 | tips_show | 10368 | |
| 19052 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 2019-11-03 16:08:25 | tips_show | 10368 |
74197 rows × 5 columns
Заменим одинаковые по смыслу значения в столбцах event_name.
data['event_name'].unique()
array(['tips_show', 'map', 'search_1', 'photos_show', 'favorites_add',
'contacts_show', 'contacts_call', 'advert_open', 'search_7',
'search_5', 'search_4', 'search_6', 'search_3', 'tips_click',
'search_2', 'show_contacts'], dtype=object)
# Создадим список значений, которые нужно будет заменить
search_replace=['search_1','search_2','search_3','search_4','search_5','search_6','search_7']
# Заменим значения
data.loc[data['event_name'].isin(search_replace),'event_name']='search'
data.loc[data['event_name']== 'show_contacts','event_name']='contacts_show'
data['event_name'].unique()
array(['tips_show', 'map', 'search', 'photos_show', 'favorites_add',
'contacts_show', 'contacts_call', 'advert_open', 'tips_click'],
dtype=object)
Посмотрим сколько и какого типа событий в данных.
data['event_name'].nunique()
9
len(data['event_name'])
74197
data['event_name'].value_counts()
tips_show 40055 photos_show 10012 search 6784 advert_open 6164 contacts_show 4529 map 3881 favorites_add 1417 tips_click 814 contacts_call 541 Name: event_name, dtype: int64
data.pivot_table(index='event_name',values='user_id', aggfunc='count').sort_values(by='user_id',ascending=False).plot(kind= 'bar')
plt.title("Распределение действий по событиям")
plt.xlabel ('Событие')
plt.ylabel ('Кол-во действий')
plt.xticks(rotation = 45)
plt.legend();
Всего в данных 9 типов и 74197 событий в данных. Больше всего пользователи совершают событие tips_show (увидели рекомендованное объявление), меньше всего пользователи совершают звонки (contacts_call)
Посмотрим, сколько пользователей в данных и сколько в среднем событий приходится на пользователя.
data['user_id'].nunique()
4293
data.groupby('user_id')['event_name'].count().describe()
count 4293.000000 mean 17.283252 std 29.130677 min 1.000000 25% 5.000000 50% 9.000000 75% 17.000000 max 478.000000 Name: event_name, dtype: float64
Среднее кол-во событий на пользователя - 17. Минимальное кол-во 1, максимальное же кол-во 478 (возможно это выбросы). Медианное значение - 9 событий на пользователя.
Посмотрим, сколько пользователей совершали каждое из событий.
events=data.groupby('event_name')['user_id'].nunique().reset_index(name='user_id').sort_values(by='user_id',ascending=False)
events
| event_name | user_id | |
|---|---|---|
| 8 | tips_show | 2801 |
| 6 | search | 1666 |
| 4 | map | 1456 |
| 5 | photos_show | 1095 |
| 2 | contacts_show | 981 |
| 0 | advert_open | 751 |
| 3 | favorites_add | 351 |
| 7 | tips_click | 322 |
| 1 | contacts_call | 213 |
# Посчитаем долю пользователей, которые хоть раз совершали событие
events['convers'] = round(events['user_id']/len(data['user_id'].unique()), 3) * 100
events
| event_name | user_id | convers | |
|---|---|---|---|
| 8 | tips_show | 2801 | 65.2 |
| 6 | search | 1666 | 38.8 |
| 4 | map | 1456 | 33.9 |
| 5 | photos_show | 1095 | 25.5 |
| 2 | contacts_show | 981 | 22.9 |
| 0 | advert_open | 751 | 17.5 |
| 3 | favorites_add | 351 | 8.2 |
| 7 | tips_click | 322 | 7.5 |
| 1 | contacts_call | 213 | 5.0 |
Мы видим, что процент конверсии в целевое действие (contacts_show) низкая, всего 22.9%.
Проведем анализ взаимодействия пользователей, кто пользовался рекомендованными объявлениями и кто переходил по объявлениям самостоятельно.
df=data.copy()
# удалим дубликаты в столбцах
df=df.drop_duplicates(subset={'user_id', 'source','event_name','session_id'})
#сгруппируем датасет по ивентам и создадим колонку с юзер стори
df_1 = df.groupby(['session_id', 'user_id'])['event_name'].apply(list).reset_index(name='event_name').sort_values(by='event_name',ascending=True)
df_1['event_name'] = df_1['event_name'].apply(lambda l: ' '.join(l))
df_2= df_1.groupby(['event_name'])['user_id'].apply(list).reset_index(name='user_id').sort_values(by='event_name',ascending=True)
#Добавим колонку с кол-вом уникальных юзеров
df_2['users_count'] = df_2['user_id'].apply(lambda l: len(set(l)))
df_2.head(5)
| event_name | user_id | users_count | |
|---|---|---|---|
| 0 | advert_open | [e13f9f32-7ae3-4204-8d60-898db040bcfc, 0d5c7fc... | 94 |
| 1 | advert_open contacts_show | [9ab0044b-12c3-4fb3-9dec-9050f46397ed, 429d753... | 7 |
| 2 | advert_open contacts_show contacts_call | [429d753a-1f31-4819-867e-2c1e36157589, 89fd43c... | 4 |
| 3 | advert_open contacts_show contacts_call photos... | [83ae922a-1d12-458a-b591-0ea6d283ce0d] | 1 |
| 4 | advert_open contacts_show map search | [b03f4137-440d-4f21-b656-7ed49a545ac0] | 1 |
df_10 = df.groupby(['session_id', 'user_id'])['event_name'].apply(list).reset_index(name='event_name')
df_10['event_name'] = df_10['event_name'].apply(lambda l: ' '.join(l))
df_10
| session_id | user_id | event_name | |
|---|---|---|---|
| 0 | 1 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show |
| 1 | 2 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | map tips_show |
| 2 | 3 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show map |
| 3 | 4 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | map tips_show |
| 4 | 5 | 00157779-810c-4498-9e05-a1e9e3cedf93 | search photos_show |
| ... | ... | ... | ... |
| 10363 | 10364 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | tips_show |
| 10364 | 10365 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | tips_show contacts_show |
| 10365 | 10366 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | tips_show contacts_show |
| 10366 | 10367 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | tips_show contacts_show |
| 10367 | 10368 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | tips_show contacts_show |
10368 rows × 3 columns
def DrawFunnelHelper(story, funnelx, funnely, data_set):
if len(story) == 0:
return data_set, funnelx, funnely;
story_step = story[0]
story.pop(0)
if story_step[0] == '!':
#добавим возможность исключать ивенты
story_step = story_step.replace("!", "")
filtered = data_set.query('event_name.str.contains(@story_step)')
users = filtered.user_id.unique()
sessions = filtered.session_id.unique()
data_set = data_set.query('session_id not in @sessions')#.query('user_id not in @users')
funnely.append("no " + story_step)
else:
data_set = data_set.query('event_name.str.contains(@story_step)')
funnely.append(story_step)
users_count = data_set.user_id.nunique()
funnelx.append(users_count)
#рекурсивно фильтруем датасет
return DrawFunnelHelper(story, funnelx, funnely, data_set)
def DrawFunnel(user_story, data_f, title):
funnel_x=[]
funnel_y=[]
filtered_df, x,y = DrawFunnelHelper(user_story, funnel_x, funnel_y, data_f)
fig = go.Figure()
fig.add_trace(go.Funnel(
y = y,
x = x,
textposition = "auto",
textinfo = "value+percent initial+percent previous",
))
fig.update_layout(title=title)
fig.show()
return filtered_df
Построим воронку взаимодействий пользователей, кто пользовался поиском.
new_data = df_10.copy()
fdf = DrawFunnel(['search','contacts_show'], new_data,"Воронка взаимодействий пользователей, кто пользовался поиском")
Вывод:
Построим воронку тех пользователей, кто пользовался рекомендательной системой.
fdf = DrawFunnel(['tips_show', 'tips_click','contacts_show'], new_data,"Воронка взаимодействий пользователей, кто пользовался рекомендованными объявлениями")
Вывод:
pd.set_option('max_colwidth', 100)
pd.set_option('display.width', 300)
# создадим датафрейм с группировкой по сессиями и юзерам
grouped_by_user = df.groupby(['session_id', 'user_id'])['event_name'].apply(list).reset_index(name='event_name').sort_values(by='session_id',ascending=True)
grouped_by_user['event_name'] = grouped_by_user['event_name'].apply(lambda l: ' '.join(l))
# выделим сценарии, которые содержат целевое событие
gr=grouped_by_user.query('event_name.str.contains("contacts_show")')
#grouped_by_user
gr_1 = gr.event_name.value_counts().to_dict()
# выделим наиболее популярные сценарии
gr['session_count'] = gr.event_name.apply(lambda x: gr_1[x])
#удалим дубликаты
gr=gr.drop_duplicates(subset='event_name',keep='first').sort_values(by='session_count',ascending=False)
gr.head(10)
| session_id | user_id | event_name | session_count | |
|---|---|---|---|---|
| 40 | 41 | 0103a07d-513f-42b9-8d91-d5891d5655fe | tips_show contacts_show | 342 |
| 9 | 10 | 00157779-810c-4498-9e05-a1e9e3cedf93 | contacts_show | 195 |
| 17 | 18 | 00551e79-152e-4441-9cf7-565d7eb04090 | contacts_show contacts_call | 120 |
| 29 | 30 | 007d031d-5018-4e02-b7ee-72a30609173f | map tips_show contacts_show | 94 |
| 60 | 61 | 01d283e1-cb1c-407a-a4e0-9f72f3deecca | photos_show contacts_show | 85 |
| 22 | 23 | 005fbea5-2678-406f-88a6-fbe9787e2268 | contacts_show tips_show | 74 |
| 18 | 19 | 00551e79-152e-4441-9cf7-565d7eb04090 | search contacts_show contacts_call | 54 |
| 113 | 114 | 03bef3ef-cce8-46ed-8c70-414b6b0486fb | search contacts_show | 52 |
| 8 | 9 | 00157779-810c-4498-9e05-a1e9e3cedf93 | search photos_show contacts_show | 45 |
| 127 | 128 | 0420f4cf-29ec-44c6-af9c-247f36efea68 | contacts_show photos_show | 38 |
Построим диаграмму Санкея, чтобы убеиться в правильности нахождения сценариев пользователей.
# создадим функцию для генерации новых столбцов для исходной таблицы
def add_features(df):
# сортируем по session_id и времени
sorted_df = df.sort_values(by=['session_id', 'event_time']).copy()
# добавляем шаги событий
sorted_df['step'] = sorted_df.groupby('session_id').cumcount() + 1
# добавляем узлы-источники и целевые узлы
# узлы-источники - это сами события
sorted_df['source'] = sorted_df['event_name']
# добавляем целевые узлы
sorted_df['target'] = sorted_df.groupby('session_id')['source'].shift(-1)
# возврат таблицы без имени событий
return sorted_df.drop(['event_name'], axis=1)
new_df = add_features(df)
new_df
| user_id | source | event_time | session_id | step | target | |
|---|---|---|---|---|---|---|
| 2171 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show | 2019-10-07 13:39:46 | 1 | 1 | NaN |
| 2180 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | map | 2019-10-09 18:33:56 | 2 | 1 | tips_show |
| 2182 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show | 2019-10-09 18:40:29 | 2 | 2 | NaN |
| 2184 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show | 2019-10-21 19:52:31 | 3 | 1 | map |
| 2186 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | map | 2019-10-21 19:53:39 | 3 | 2 | NaN |
| ... | ... | ... | ... | ... | ... | ... |
| 19021 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | contacts_show | 2019-11-02 19:26:08 | 10366 | 2 | NaN |
| 19024 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | tips_show | 2019-11-03 14:32:56 | 10367 | 1 | contacts_show |
| 19025 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | contacts_show | 2019-11-03 14:33:48 | 10367 | 2 | NaN |
| 19039 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | tips_show | 2019-11-03 15:36:01 | 10368 | 1 | contacts_show |
| 19044 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | contacts_show | 2019-11-03 15:48:05 | 10368 | 2 | NaN |
17854 rows × 6 columns
new_df.groupby('session_id')['source'].nunique().describe()
count 10368.000000 mean 1.722029 std 0.893418 min 1.000000 25% 1.000000 50% 1.000000 75% 2.000000 max 6.000000 Name: source, dtype: float64
# удалим все пары source-target, шаг которых превышает 6
df_comp = new_df[new_df['step'] < 6].copy().reset_index(drop=True)
# создадим функцию для генерации индексов source
def get_source_index(df):
res_dict = {}
count = 0
# получаем индексы источников
for no, step in enumerate(new_df['step'].unique().tolist()):
# получаем уникальные наименования для шага
res_dict[no+1] = {}
res_dict[no+1]['sources'] = new_df[new_df['step'] == step]['source'].unique().tolist()
res_dict[no+1]['sources_index'] = []
for i in range(len(res_dict[no+1]['sources'])):
res_dict[no+1]['sources_index'].append(count)
count += 1
# соединим списки
for key in res_dict:
res_dict[key]['sources_dict'] = {}
for name, no in zip(res_dict[key]['sources'], res_dict[key]['sources_index']):
res_dict[key]['sources_dict'][name] = no
return res_dict
source_indexes = get_source_index(df_comp)
# создадим функцию для вывода данных для конкретного шага будущей диаграммы
def show_example(step, source_indexes=source_indexes):
print(f'Пример подготовленных данных для шага {step}\n')
for key in source_indexes[step]:
print(f'{key}\n', source_indexes[step][key], '\n')
show_example(1)
Пример подготовленных данных для шага 1
sources
['tips_show', 'map', 'search', 'photos_show', 'contacts_show', 'advert_open', 'favorites_add', 'tips_click']
sources_index
[0, 1, 2, 3, 4, 5, 6, 7]
sources_dict
{'tips_show': 0, 'map': 1, 'search': 2, 'photos_show': 3, 'contacts_show': 4, 'advert_open': 5, 'favorites_add': 6, 'tips_click': 7}
# создадим функцию для генерации цветов rgba
def colors_for_sources(mode):
# словарь, в который сложим цвета в соответствии с индексом
colors_dict = {}
if mode == 'random':
# генерим случайные цвета
for label in df_comp['source'].unique():
r, g, b = np.random.randint(255, size=3)
colors_dict[label] = f'rgba({r}, {g}, {b}, 1)'
elif mode == 'custom':
# присваиваем ранее подготовленные цвета
colors = requests.get('https://raw.githubusercontent.com/rusantsovsv/senkey_tutorial/main/json/colors_senkey.json').json()
for no, label in enumerate(df_comp['source'].unique()):
colors_dict[label] = colors['custom_colors'][no]
return colors_dict
colors_dict = colors_for_sources(mode='custom')
# пересчитаем количестов юзеров в процентах от входа
def percent_users(sources, targets, values):
# объединим источники и метки и найдем пары
zip_lists = list(zip(sources, targets, values))
new_list = []
# подготовим список словарь с общим объемом трафика в узлах
unique_dict = {}
# проходим по каждому узлу
for source, target, value in zip_lists:
if source not in unique_dict:
# находим все источники и считаем общий трафик
unique_dict[source] = 0
for sr, tg, vl in zip_lists:
if sr == source:
unique_dict[source] += vl
# считаем проценты
for source, target, value in zip_lists:
new_list.append(round(100 * value / unique_dict[source], 1))
return new_list
# сделаем функцию для создания необходимых для отрисовки диаграммы переменных списков
def lists_for_plot(source_indexes=source_indexes, colors=colors_dict, frac=10):
sources = []
targets = []
values = []
labels = []
link_color = []
link_text = []
# проходим по каждому шагу
for step in tqdm(sorted(df_comp['step'].unique()), desc='Шаг'):
if step + 1 not in source_indexes:
continue
# получаем индекс источника
temp_dict_source = source_indexes[step]['sources_dict']
# получаем индексы цели
temp_dict_target = source_indexes[step+1]['sources_dict']
# проходим по каждой возможной паре, считаем количество таких пар
for source, index_source in tqdm(temp_dict_source.items()):
for target, index_target in temp_dict_target.items():
# делаем срез данных и считаем количество id
temp_df = df_comp[(df_comp['step'] == step)&(df_comp['source'] == source)&(df_comp['target'] == target)]
value = len(temp_df)
# проверяем минимальный объем потока и добавляем нужные данные
if value > frac:
sources.append(index_source)
targets.append(index_target)
values.append(value)
# делаем поток прозрачным для лучшего отображения
link_color.append(colors[source].replace(', 1)', ', 0.2)'))
labels = []
colors_labels = []
for key in source_indexes:
for name in source_indexes[key]['sources']:
labels.append(name)
colors_labels.append(colors[name])
# посчитаем проценты всех потоков
perc_values = percent_users(sources, targets, values)
# добавим значения процентов для howertext
link_text = []
for perc in perc_values:
link_text.append(f"{perc}%")
# возвратим словарь с вложенными списками
return {'sources': sources,
'targets': targets,
'values': values,
'labels': labels,
'colors_labels': colors_labels,
'link_color': link_color,
'link_text': link_text}
data_for_plot = lists_for_plot()
Шаг: 0%| | 0/5 [00:00<?, ?it/s]
0%| | 0/8 [00:00<?, ?it/s]
0%| | 0/9 [00:00<?, ?it/s]
0%| | 0/9 [00:00<?, ?it/s]
0%| | 0/9 [00:00<?, ?it/s]
0%| | 0/8 [00:00<?, ?it/s]
# создадим функцию для генерации объекта диаграммы Сенкей
def plot_senkey_diagram(data_dict=data_for_plot):
fig = go.Figure(data=[go.Sankey(
domain = dict(
x = [0,1],
y = [0,1]
),
orientation = "h",
valueformat = ".0f",
node = dict(
pad = 50,
thickness = 15,
line = dict(color = "black", width = 0.1),
label = data_dict['labels'],
color = data_dict['colors_labels']
),
link = dict(
source = data_dict['sources'],
target = data_dict['targets'],
value = data_dict['values'],
label = data_dict['link_text'],
color = data_dict['link_color']
))])
fig.update_layout(title_text="Sankey Diagram", font_size=10, width=1000, height=600)
# возвращаем объект диаграммы
return fig
senkey_diagram = plot_senkey_diagram()
senkey_diagram.show()
Построим воронки по этим сценарием.
fdf = DrawFunnel(['tips_show','contacts_show'], new_data,"Сценарий 'tips_show'-'contacts_show' ")
fdf = DrawFunnel(['map','tips_show','contacts_show'], new_data,"Сценарий 'map'-'tips_show'-'contacts_show' ")
fdf = DrawFunnel(['photos_show','contacts_show'], new_data,"Сценарий 'photos_show'-'contacts_show' ")
fdf = DrawFunnel(['search','photos_show','contacts_show'], new_data,"Сценарий 'search'-'photos_show'-'contacts_show' ")
Вывод:
Наиболее популярными сценариями действий пользователя, приводящих к целевому событию являются:
tips_show - contacts_show составляет 18%.map-tips_show-contacts_show составляет 14%.photos_show-contacts_show 24%.search-photos_show-contacts_show составляет 7%.На основе анализа можно сказать, что пользователи чаще просматривают контакты из рекомендованных объявлений и если просматривают фотографии.
Проанализируем, как различается время между событиями ADVERT_OPEN -> CONTACTS_SHOW И TIPS_CLICK -> CONTACTS_SHOW.Какая конверсия в целевое событие у данных действий?
Рассмотрим, как различается время между событиями ADVERT_OPEN -> CONTACTS_SHOW
# выделим сессии, в которых пользователи совершали advert_open и время первого совершения
advert=df[df['event_name'] == "advert_open"].groupby('user_id',as_index=False).agg(first_time=('event_time','min'))
# найдем уникальных пользователей
advert_users=advert['user_id'].nunique()
advert_users
751
# выделим сессии, в которых пользователи совершали contacts_show
contacts = (df[df['event_name'] == "contacts_show"].merge(advert,on='user_id',how='left'))
#отфильтруем события, случившиеся после advert_name
contacts=contacts[contacts['event_time']>contacts['first_time']]
# найдем уникальных пользователей
contacts_users=contacts['user_id'].nunique()
contacts_users
109
# найдем конверсию
advert_conver=contacts_users/advert_users
advert_conver
0.14513981358189082
Конверсия в целевое событие 14.5%
Далее найдем разницу между событиями ADVERT_OPEN -> CONTACTS_SHOW
#создадми датасет со временем всех ивентов
grouped_by_time = df.groupby(['session_id', 'user_id'])['event_time'].apply(list).reset_index(name='event_time').sort_values(by='session_id',ascending=True)
#создадим датасет с именем ивентов
grouped_by_events = df.groupby(['session_id', 'user_id'])['event_name'].apply(list).reset_index(name='event_name').sort_values(by='session_id',ascending=True)
#добавим колонку со сременем в датасет
grouped_by_time['event_name'] = grouped_by_events['event_name']
grouped_by_time.head(5)
| session_id | user_id | event_time | event_name | |
|---|---|---|---|---|
| 0 | 1 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | [2019-10-07 13:39:46] | [tips_show] |
| 1 | 2 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | [2019-10-09 18:33:56, 2019-10-09 18:40:29] | [map, tips_show] |
| 2 | 3 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | [2019-10-21 19:52:31, 2019-10-21 19:53:39] | [tips_show, map] |
| 3 | 4 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | [2019-10-22 11:18:15, 2019-10-22 11:19:11] | [map, tips_show] |
| 4 | 5 | 00157779-810c-4498-9e05-a1e9e3cedf93 | [2019-10-19 21:34:34, 2019-10-19 21:40:39] | [search, photos_show] |
# выделим сессии, в которых пользователи совершали advert_open
advert_session=df.query('event_name == "advert_open"')['session_id']
# выделим сессии, в которых пользователи совершали contacts_show
contacts_show_session = df.query('event_name == "contacts_show"')['session_id']
# найдем сессии, в которых были все 2 типа действий
advert = set(advert_session) & set(contacts_show_session)
#создадим фукнцию, которая будет создавать словарь event_time-event_name
def mergeEventTime(ser):
return dict(zip(ser[0], ser[1]))
#создадим датасет с нужными сессиями
advert_data = grouped_by_time.query('session_id in @advert')
#создадим колонку со списком ивенты-время
advert_data['time_event']=list(zip(advert_data.event_name, advert_data.event_time))
#смержим два списка в словарь ивент-время
advert_data['time_event'] = advert_data.time_event.apply(mergeEventTime)
#создадим колонки start_time и end_time с нужным временм из словаря
advert_data['start_time'] = advert_data.time_event.apply(lambda x: x['advert_open'])
advert_data['end_time'] = advert_data.time_event.apply(lambda x: x['contacts_show'])
#приведем колонки к типу datetime
advert_data['start_time']=pd.to_datetime(advert_data['start_time'], format='%Y-%m-%d %H:%M:%S').dt.round('1S')
advert_data['end_time']=pd.to_datetime(advert_data['end_time'], format='%Y-%m-%d %H:%M:%S').dt.round('1S')
#вычислим разницу
advert_data['duration'] = advert_data['end_time'] - advert_data['start_time']
# переведем значения в секунды
advert_data['duration']=advert_data['duration'].dt.total_seconds()
#отбросим отрицательные значения
advert_data=advert_data.query("duration >= 0")
advert_data.describe()
| session_id | duration | |
|---|---|---|
| count | 97.000000 | 97.000000 |
| mean | 4929.701031 | 481.649485 |
| std | 2878.669442 | 741.526270 |
| min | 151.000000 | 3.000000 |
| 25% | 2246.000000 | 61.000000 |
| 50% | 5282.000000 | 178.000000 |
| 75% | 7103.000000 | 592.000000 |
| max | 10252.000000 | 4251.000000 |
Медианное время между действиями advert_open и contacts_show около 178 секунды (около 3 минут).
# выделим сессии, в которых пользователи совершали advert_open и время первого совершения
tips_click=df[df['event_name'] == "tips_click"].groupby('user_id',as_index=False).agg(first_time=('event_time','min'))
# найдем уникальных пользователей
tips_users=tips_click['user_id'].nunique()
tips_users
322
# выделим сессии, в которых пользователи совершали contacts_show
tips_contacts = (df[df['event_name'] == "contacts_show"].merge(tips_click,on='user_id',how='left'))
#отфильтруем события, случившиеся после advert_name
tips_contacts=tips_contacts[tips_contacts['event_time']>tips_contacts['first_time']]
# найдем уникальных пользователей
tips_contacts_users=tips_contacts['user_id'].nunique()
tips_contacts_users
59
# найдем конверсию
tips_conver=tips_contacts_users/tips_users
tips_conver
0.18322981366459629
Конверсия в целевое событие 18.3%
# Найдем сессии, в которых пользователь совершал tips_click
tips_session = df.query('event_name == "tips_click"')['session_id'].unique()
# найдем сессии, в которых были все 2 типа действий
tips = set(tips_session) & set(contacts_show_session)
#создадим датасет с нужными сессиями
tips_data = grouped_by_time.query('session_id in @tips')
#создадим колонку со списком ивенты-время
tips_data['time_event']=list(zip(tips_data.event_name, tips_data.event_time))
#смержим два списка в словарь ивент-время
tips_data['time_event'] = tips_data.time_event.apply(mergeEventTime)
#создадим колонки start_time и end_time с нужным временм из словаря
tips_data['start_time'] = tips_data.time_event.apply(lambda x: x['tips_click'])
tips_data['end_time'] = tips_data.time_event.apply(lambda x: x['contacts_show'])
#приведем колонки к типу datetime
tips_data['start_time']=pd.to_datetime(tips_data['start_time'], format='%Y-%m-%d %H:%M:%S').dt.round('1S')
tips_data['end_time']=pd.to_datetime(tips_data['end_time'], format='%Y-%m-%d %H:%M:%S').dt.round('1S')
#вычислим разницу
tips_data['duration'] = tips_data['end_time'] - tips_data['start_time']
# переведем значения в секунды
tips_data['duration']=tips_data['duration'].dt.total_seconds()
#отбросим отрицательные значения
tips_data=tips_data.query("duration >= 0")
tips_data.describe()
| session_id | duration | |
|---|---|---|
| count | 28.000000 | 28.0000 |
| mean | 5155.357143 | 815.7500 |
| std | 2976.691156 | 808.6481 |
| min | 538.000000 | 18.0000 |
| 25% | 1904.250000 | 235.0000 |
| 50% | 4889.000000 | 471.0000 |
| 75% | 7499.250000 | 1194.2500 |
| max | 10022.000000 | 3048.0000 |
Среднее время между действиями tips_click и contacts_show около 471 секунды (около 7 минут).
Пользователь при открытии рекомендованного объявления проводит в карточке объявления около 7 минут и потом нажимает на показ контактов, конверсия 18.3%.
При открытии объявления, кроме рекомендованного, пользователь проводит 3 минут и конверсия в просмотр контактов составляет 14.5%.
Конверсия в целевое событие пользователей, которые самостояльно нашли объявление выше.
click_df = DrawFunnel(['tips_show', 'tips_click'], new_data, 'tips_show-tips_click')
click_show_df = DrawFunnel(['tips_show', 'tips_click','contacts_show'], new_data, 'tips_show-tips_click-contacts_show')
no_click_df = DrawFunnel(['tips_show', '!tips_click'], new_data, 'tips_show-!tips_click')
no_click_show_df = DrawFunnel(['tips_show', '!tips_click','contacts_show'], new_data, 'tips_show-!tips_click-contacts_show')
# КОД РЕВЬЮЕРА
df.query('event_name == "tips_show"')['user_id'].nunique()
2801
no_click_count = no_click_df.user_id.nunique()
click_count = click_df.user_id.nunique()
no_click_show_count = no_click_show_df.user_id.nunique()
click_show_count = click_show_df.user_id.nunique()
Определим гипотезы:
H0- Статистически значимой разницы в конверсии между контрольными нет.
H1- Статистически значимая разница в конверсии между конрольными группами есть.
alpha = 0.05 # критический уровень статистической значимости
target_action= np.array([no_click_count,click_count])
user_count = np.array([no_click_show_count, click_show_count] )
print(user_count, target_action) # КОД РЕВЬЮЕРА!!
# пропорция успехов в первой группе:
p1 = user_count[0]/target_action[0]
# пропорция успехов во второй группе:
p2 = user_count[1]/target_action[1]
# пропорция успехов в комбинированном датасете:
p_combined = (user_count[0] + user_count[1]) / (target_action[0] + target_action[1])
# разница пропорций в датасетах
difference = p1 - p2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/target_action[0] + 1/target_action[1]))
# задаем стандартное нормальное распределение
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:# ваш код
print('Отвергаем нулевую гипотезу: между конверсиями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать конверсии разными'
)
[459 63] [2691 291] p-значение: 0.05017555576325239 Не получилось отвергнуть нулевую гипотезу, нет оснований считать конверсии разными
По полученным исследованиям, мы можем сделать вывод, что нет оснований считать, что конверсии разные.
Конверсия для пользователей только tips_show:
no_click_show_count/no_click_count
0.1705685618729097
Конверсия для пользователей tips_show и tips_click:
click_show_count/click_count
0.21649484536082475
Вывод:
Пользователи, которые совершают действия tips_show и tips_click имеют конверсию в целевое действие около 21.6%, пользователи же, которые совершают только tips_show 16.2%, что может говорить о том, что пользователи,просматривающие рекомендованные объявления чаще просматривают контакты.
# КОД РЕВЬЮЕРА
# формируем датафрейм с заданным действием и первое время его совершения
photo_event_df = (df[df['event_name'] == 'photos_show']
.groupby('user_id', as_index=False)
.agg(photo_event_time=('event_time', 'min')))
# количество пользователей которые совершили photos_show
photo_event_df['user_id'].nunique()
1095
# КОД РЕВЬЮЕРА
# находим пользователей которые совершили контакты и мерджим с предыдущим датафреймом
call_user_df = (df[df['event_name'] == 'contacts_call']
.merge(photo_event_df, on='user_id', how='left'))
# фильтруем, чтобы остались только те целевые события, что были после tips_click
call_user_df = call_user_df[call_user_df['event_time'] > call_user_df['photo_event_time']]
# считаем сколько пользователей
call_user_df['user_id'].nunique()
127
# создадим датайфрейм с пользовпателями, которые совершали photos_show
photo_event_df = (df[df['event_name'] == 'photos_show']
.groupby('user_id', as_index=False)
.agg(photo_event_time=('event_time', 'min')))
# находим пользователей которые совершили звонки и мерджим с предыдущим датафреймом
call_user_df = (df[df['event_name'] == 'contacts_call']
.merge(photo_event_df, on='user_id', how='left'))
# фильтруем, чтобы остались только те целевые события, что были после photow_show
call_user_df = call_user_df[call_user_df['event_time'] > call_user_df['photo_event_time']]
# создадим лист с теми, кто совершал photos_show
photos_show=photo_event_df['user_id'].unique().tolist()
# созадим датайфрейм с теми, кто не совершал photos_show
no_photo_event_df=(df.query('user_id not in @photos_show')
.groupby('user_id', as_index=False)
.agg(no_photo_event_time=('event_time', 'min')))
# создадим лист с теми, кто совершал contacts_call
no_call=call_user_df['user_id'].unique().tolist()
# находим пользователей которые не совершили звонки и мерджим с предыдущим датафреймом
no_call_user_df = (df[df['event_name'] == 'contacts_call']
.merge(no_photo_event_df, on='user_id', how='left'))
# фильтруем, чтобы остались только те пользователи, которые не совершали contacts_call
no_call_user_df=no_call_user_df.query('user_id not in @no_call')
photos_count = photo_event_df.user_id.nunique()
no_photos_count = no_photo_event_df.user_id.nunique()
photos_call_count = call_user_df.user_id.nunique()
no_photos_call_count = no_call_user_df.user_id.nunique()
Определим гипотезы:
H0- Статистически значимой разницы в конверсии между контрольными нет.
H1- Статистически значимая разница в конверсии между конрольными группами есть.
alpha = 0.05 # критический уровень статистической значимости
user_count = np.array([photos_call_count,no_photos_call_count])
target_action = np.array([photos_count, no_photos_count] )
print(user_count, target_action) # КОД РЕВЬЮЕРА!!
# пропорция успехов в первой группе:
p1 = user_count[0]/target_action[0]
# пропорция успехов во второй группе:
p2 = user_count[1]/target_action[1]
# пропорция успехов в комбинированном датасете:
p_combined = (user_count[0] + user_count[1]) / (target_action[0] + target_action[1])
# разница пропорций в датасетах
difference = p1 - p2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/target_action[0] + 1/target_action[1]))
# задаем стандартное нормальное распределение
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:# ваш код
print('Отвергаем нулевую гипотезу: между конверсиями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать конверсии разными'
)
[127 86] [1095 3198] p-значение: 0.0 Отвергаем нулевую гипотезу: между конверсиями есть значимая разница
Конверсия для тех, кто совершал photos_show
photos_call_count/photos_count
0.11598173515981736
Конверсия для тех, кто не совершал photos_show
no_photos_call_count/no_photos_count
0.02689180737961226
Вывод: Пользователи, которые просматривали фото имеют конверсию в целевое действие 11.5%, те, кто не смотрел фото имеют конверсию в 2.6%. Разница большая, что говорит о том, что просмотр фото увеличивает вероятность звонка.
В анализе мы использовали данные пользователей, впервые совершивших действия в приложении после 7 октября 2019 года.
Мы провели следующие исследования и сделали выводы:
Изучение и предобработка данных
mobile_dataset и mobile_sources). В них мы поменяли названия столбов и объединили в единый датасет.session_id, выделив тайм-аут времени в 30 мин.search и contacts_showИсследовательский анализ данных
Провели анализ взаимодействия пользователей, кто пользовался рекомендованными объявлениями и кто переходил по объявлениям самостоятельно, получили следующие выводы:
Для тех пользователей, кто пользовался поиском, конверсия в просмотры контактов около 17%
Для тех пользователей, кто переходил по рекомендованным объявлениям, всего 2% пользователей, просмотрели контакты рекомендованных объявлений.
Мы провели поиск сценариев пользователей и построили диаграмму Санкея и получили следующие выводы:
tips_show - contacts_show составляет 18%.map-tips_show-contacts_show составляет 14%.photos_show-contacts_show 24%.search-photos_show-contacts_show составляет 7%.Сделали анализ влияния событий на совершение целевого события:
ADVERT_OPEN -> CONTACTS_SHOW
TIPS_CLICK -> CONTACTS_SHOW
Конверсия в целевое событие пользователей, которые перешли по рекомендациям.
Пользователи, которые совершают действия tips_show и tips_click имеют конверсию в целевое действие около 21.6%, пользователи же, которые совершают только tips_show 16.2%, что может говорить о том, что пользователи,просматривающие рекомендованные объявления чаще просматривают контакты.
Пользователи, которые просматривали фото имеют конверсию в целевое действие 11.5%, те, кто не смотрел фото имеют конверсию в 2.6%. Разница большая, что говорит о том, что просмотр фото увеличивает вероятность звонка.
На основе анализа можно сказать, что пользователи чаще просматривают контакты из рекомендованных объявлений и если просматривают фотографии.
Общий вывод:
Лучше всего в целевое событие конвертируются события, которые пользователи смотрят из рекомендованных объявлений.Также наиболее популярными сценариями являются сценарии с рекомендованными объявлениями и просмотром фото. Чаще звонят пользователи, которые просмотрели фото. Заказчику стоит улучшать карточку объявления и улучшать рекомендательную систему в приложении.
Ссылка на презентацию: https://disk.yandex.ru/i/nCeXQU-vB2aBfw